#!/usr/bin/env python3

# Provides a somewhat random, somewhat compat, somewhat useful mock version of
# http://docs.amazonwebservices.com
#   /AWSEC2/2007-08-29/DeveloperGuide/AESDG-chapter-instancedata.htm

"""
To use this to mimic the EC2 metadata service entirely, run it like:
  # Where 'eth0' is *some* interface.
  sudo ifconfig eth0:0 169.254.169.254 netmask 255.255.255.255

  sudo ./mock-meta.py -a 169.254.169.254 -p 80

Then:
  wget -q http://169.254.169.254/latest/meta-data/instance-id -O -; echo
  curl --silent http://169.254.169.254/latest/meta-data/instance-id ; echo
  ec2metadata --instance-id
"""

import argparse
import functools
import json
import logging
import os
import random
import socket
import string
import sys
import yaml

try:
    from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
    import httplib as hclient
except ImportError:
    from http.server import HTTPServer, BaseHTTPRequestHandler
    from http import client as hclient


log = logging.getLogger("meta-server")

EC2_VERSIONS = [
    "1.0",
    "2007-01-19",
    "2007-03-01",
    "2007-08-29",
    "2007-10-10",
    "2007-12-15",
    "2008-02-01",
    "2008-09-01",
    "2009-04-04",
]

BLOCK_DEVS = [
    "ami",
    "ephemeral0",
    "root",
]

DEV_PREFIX = "v"  # This seems to vary a lot depending on images...
DEV_MAPPINGS = {
    "ephemeral0": "%sda2" % (DEV_PREFIX),
    "root": "/dev/%sda1" % (DEV_PREFIX),
    "ami": "%sda1" % (DEV_PREFIX),
    "swap": "%sda3" % (DEV_PREFIX),
}

META_CAPABILITIES = [
    "aki-id",
    "ami-id",
    "ami-launch-index",
    "ami-manifest-path",
    "ari-id",
    "block-device-mapping/",
    "hostname",
    "instance-action",
    "instance-id",
    "instance-type",
    "local-hostname",
    "local-ipv4",
    "placement/",
    "product-codes",
    "public-hostname",
    "public-ipv4",
    "public-keys/",
    "reservation-id",
    "security-groups",
]

PUB_KEYS = {
    "brickies": [
        "ssh-rsa "
        "AAAAB3NzaC1yc2EAAAABIwAAAQEA3I7VUf2l5gSn5uavROsc5HRDpZdQueUq5ozemN"
        "Sj8T7enqKHOEaFoU2VoPgGEWC9RyzSQVeyD6s7APMcE82EtmW4skVEgEGSbDc1pvxz"
        "xtchBj78hJP6Cf5TCMFSXw+Fz5rF1dR23QDbN1mkHs7adr8GW4kSWqU7Q7NDwfIrJJ"
        "tO7Hi42GyXtvEONHbiRPOe8stqUly7MvUoN+5kfjBM8Qqpfl2+FNhTYWpMfYdPUnE7"
        "u536WqzFmsaqJctz3gBxH9Ex7dFtrxR4qiqEr9Qtlu3xGn7Bw07/+i1D+ey3ONkZLN"
        "+LQ714cgj8fRS4Hj29SCmXp5Kt5/82cD/VN3NtHw== brickies",
        "",
    ],
}

INSTANCE_TYPES = [
    "m1.large",
    "m1.medium",
    "m1.small",
    "m1.xlarge",
]

AVAILABILITY_ZONES = [
    "us-east-1a",
    "us-east-1b",
    "us-east-1c",
    "us-east-1d",
    "eu-west-1a",
    "eu-west-1b",
    "us-west-1",
]

PLACEMENT_CAPABILITIES = {
    "availability-zone": AVAILABILITY_ZONES,
}

NOT_IMPL_RESPONSE = json.dumps({})


class WebException(Exception):
    def __init__(self, code, msg):
        Exception.__init__(self, msg)
        self.code = code


def yamlify(data):
    formatted = yaml.dump(
        data,
        line_break="\n",
        indent=4,
        explicit_start=True,
        explicit_end=True,
        default_flow_style=False,
    )
    return formatted


def format_text(text):
    if not len(text):
        return "<<"
    lines = text.splitlines()
    nlines = []
    for line in lines:
        nlines.append("<< %s" % line)
    return "\n".join(nlines)


def traverse(keys, mp):
    result = dict(mp)
    for k in keys:
        try:
            result = result.get(k)
        except (AttributeError, TypeError):
            result = None
            break
    return result


ID_CHARS = [c for c in (string.ascii_uppercase + string.digits)]


def id_generator(size=6, lower=False):
    txt = "".join(random.choice(ID_CHARS) for x in range(size))
    if lower:
        return txt.lower()
    else:
        return txt


def get_ssh_keys():
    keys = {}
    keys.update(PUB_KEYS)

    # Nice helper to add in the 'running' users key (if they have one)
    key_pth = os.path.expanduser("~/.ssh/id_rsa.pub")

    if os.path.isfile(key_pth):
        with open(key_pth, "rb") as fh:
            contents = fh.read()
        keys[os.getlogin()] = [contents, ""]

    return keys


class HTTPServerV6(HTTPServer):
    address_family = socket.AF_INET6


class MetaDataHandler:
    def __init__(self, opts):
        self.opts = opts
        self.instances = {}

    def get_data(self, params, who, **kwargs):
        if not params:
            # Show the root level capabilities when
            # no params are passed...
            caps = sorted(META_CAPABILITIES)
            return "\n".join(caps)
        action = params[0]
        action = action.lower()
        if action == "instance-id":
            return "i-%s" % (id_generator(lower=True))
        elif action == "ami-launch-index":
            return "%s" % random.choice([0, 1, 2, 3])
        elif action == "aki-id":
            return "aki-%s" % (id_generator(lower=True))
        elif action == "ami-id":
            return "ami-%s" % (id_generator(lower=True))
        elif action == "ari-id":
            return "ari-%s" % (id_generator(lower=True))
        elif action == "block-device-mapping":
            nparams = params[1:]
            if not nparams:
                return "\n".join(BLOCK_DEVS)
            else:
                subvalue = traverse(nparams, DEV_MAPPINGS)
                if not subvalue:
                    return "\n".join(sorted(list(DEV_MAPPINGS.keys())))
                else:
                    return str(subvalue)
        elif action in ["hostname", "local-hostname", "public-hostname"]:
            # Just echo back there own hostname that they called in on..
            return "%s" % (who)
        elif action == "instance-type":
            return random.choice(INSTANCE_TYPES)
        elif action == "ami-manifest-path":
            return "my-amis/spamd-image.manifest.xml"
        elif action == "security-groups":
            return "default"
        elif action in ["local-ipv4", "public-ipv4"]:
            # Just echo back there own ip that they called in on...
            return "%s" % (kwargs.get("client_ip", "10.0.0.1"))
        elif action == "reservation-id":
            return "r-%s" % (id_generator(lower=True))
        elif action == "product-codes":
            return "%s" % (id_generator(size=8))
        elif action == "public-keys":
            nparams = params[1:]
            # This is a weird kludge, why amazon why!!!
            # public-keys is messed up, list of /latest/meta-data/public-keys/
            # shows something like: '0=brickies'
            # but a GET to /latest/meta-data/public-keys/0=brickies will fail
            # you have to know to get '/latest/meta-data/public-keys/0', then
            # from there you get a 'openssh-key', which you can get.
            # this hunk of code just re-works the object for that.
            avail_keys = get_ssh_keys()
            key_ids = sorted(list(avail_keys.keys()))
            if nparams:
                mybe_key = nparams[0]
                try:
                    key_id = int(mybe_key)
                    key_name = key_ids[key_id]
                except ValueError as e:
                    raise WebException(
                        hclient.BAD_REQUEST, "%s: not an integer" % mybe_key
                    ) from e
                except IndexError as e:
                    raise WebException(
                        hclient.NOT_FOUND, "Unknown key id %r" % mybe_key
                    ) from e
                # Extract the possible sub-params
                result = traverse(
                    nparams[1:],
                    {
                        "openssh-key": "\n".join(avail_keys[key_name]),
                    },
                )
                if isinstance(result, (dict)):
                    # TODO(harlowja): This might not be right??
                    result = "\n".join(sorted(result.keys()))
                if not result:
                    result = ""
                return result
            else:
                contents = []
                for i, key_id in enumerate(key_ids):
                    contents.append("%s=%s" % (i, key_id))
                return "\n".join(contents)
        elif action == "placement":
            nparams = params[1:]
            if not nparams:
                pcaps = sorted(PLACEMENT_CAPABILITIES.keys())
                return "\n".join(pcaps)
            else:
                pentry = nparams[0].strip().lower()
                if pentry == "availability-zone":
                    zones = PLACEMENT_CAPABILITIES[pentry]
                    return "%s" % random.choice(zones)
                else:
                    return "%s" % (PLACEMENT_CAPABILITIES.get(pentry, ""))
        else:
            log.warning(
                "Did not implement action %s, returning empty response: %r",
                action,
                NOT_IMPL_RESPONSE,
            )
            return NOT_IMPL_RESPONSE


class UserDataHandler:
    def __init__(self, opts):
        self.opts = opts

    def _get_user_blob(self, **kwargs):
        blob = None
        if self.opts["user_data_file"] is not None:
            blob = self.opts["user_data_file"]
        if not blob:
            blob_mp = {
                "hostname": kwargs.get("who", "localhost"),
            }
            lines = [
                "#cloud-config",
                yamlify(blob_mp),
            ]
            blob = "\n".join(lines)
        return blob.strip()

    def get_data(self, params, who, **kwargs):
        if not params:
            return self._get_user_blob(who=who)
        return NOT_IMPL_RESPONSE


# Seem to need to use globals since can't pass
# data into the request handlers instances...
# Puke!
meta_fetcher = None
user_fetcher = None


class Ec2Handler(BaseHTTPRequestHandler):
    def _get_versions(self):
        versions = ["latest"] + EC2_VERSIONS
        versions = sorted(versions)
        return "\n".join(versions)

    def log_message(self, fmt, *args):
        msg = "%s - %s" % (self.address_string(), fmt % (args))
        log.info(msg)

    def _find_method(self, path):
        # Puke! (globals)
        func_mapping = {
            "user-data": user_fetcher.get_data,
            "meta-data": meta_fetcher.get_data,
        }
        segments = [piece for piece in path.split("/") if len(piece)]
        log.info("Received segments %s", segments)
        if not segments:
            return self._get_versions
        date = segments[0].strip().lower()
        if date not in self._get_versions():
            raise WebException(
                hclient.BAD_REQUEST, "Unknown version format %r" % date
            )
        if len(segments) < 2:
            raise WebException(hclient.BAD_REQUEST, "No action provided")
        look_name = segments[1].lower()
        if look_name not in func_mapping:
            raise WebException(
                hclient.BAD_REQUEST, "Unknown requested data %r" % look_name
            )
        base_func = func_mapping[look_name]
        who = self.address_string()
        ip_from = self.client_address[0]
        if who == ip_from:
            # Nothing resolved, so just use 'localhost'
            who = "localhost"
        kwargs = {
            "params": list(segments[2:]),
            "who": who,
            "client_ip": ip_from,
        }
        return functools.partial(base_func, **kwargs)

    def _do_response(self):
        who = self.client_address
        log.info("Got a call from %s for path %s", who, self.path)
        try:
            func = self._find_method(self.path)
            data = func()
            if not data:
                data = ""
            self.send_response(hclient.OK)
            self.send_header("Content-Type", "binary/octet-stream")
            self.send_header("Content-Length", len(data))
            log.info(
                "Sending data (len=%s):\n%s", len(data), format_text(data)
            )
            self.end_headers()
            self.wfile.write(data.encode())
        except RuntimeError as e:
            log.exception("Error somewhere in the server.")
            self.send_error(hclient.INTERNAL_SERVER_ERROR, message=str(e))
        except WebException as e:
            code = e.code
            log.exception(str(e))
            self.send_error(code, message=str(e))

    def do_GET(self):
        self._do_response()

    def do_POST(self):
        self._do_response()


def setup_logging(log_level, fmt="%(levelname)s: @%(name)s : %(message)s"):
    root_logger = logging.getLogger()
    console_logger = logging.StreamHandler(sys.stdout)
    console_logger.setFormatter(logging.Formatter(fmt))
    root_logger.addHandler(console_logger)
    root_logger.setLevel(log_level)


def extract_opts():
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "-p",
        "--port",
        dest="port",
        action="store",
        type=int,
        default=80,
        metavar="PORT",
        help="port from which to serve traffic (default: %default)",
    )
    parser.add_argument(
        "-a",
        "--addr",
        dest="address",
        action="store",
        type=str,
        default="::",
        metavar="ADDRESS",
        help="address from which to serve traffic (default: %default)",
    )
    parser.add_argument(
        "-f",
        "--user-data-file",
        dest="user_data_file",
        action="store",
        metavar="FILE",
        help="user data filename to serve back toincoming requests",
    )
    parser.add_argument("extra", nargs="*")
    args = parser.parse_args()
    out = {
        "port": args.port,
        "address": args.address,
        "extra": args.extra,
        "user_data_file": None,
    }
    if args.user_data_file:
        if not os.path.isfile(args.user_data_file):
            parser.error("Option -f specified a non-existent file")
        with open(args.user_data_file, "rb") as fh:
            out["user_data_file"] = fh.read()
    return out


def setup_fetchers(opts):
    global meta_fetcher
    global user_fetcher
    meta_fetcher = MetaDataHandler(opts)
    user_fetcher = UserDataHandler(opts)


def run_server():
    # Using global here since it doesn't seem like we
    # can pass opts into a request handler constructor...
    opts = extract_opts()
    setup_logging(logging.DEBUG)
    setup_fetchers(opts)
    log.info("CLI opts: %s", opts)
    server_address = (opts["address"], opts["port"])
    server = HTTPServerV6(server_address, Ec2Handler)
    sa = server.socket.getsockname()
    log.info("Serving ec2 metadata on %s using port %s ...", sa[0], sa[1])
    server.serve_forever()


if __name__ == "__main__":
    run_server()
